test

5.7 常见性能瓶颈与规避方法

在之前几章中都有与常见陷阱和伪优化相关的内容,在做基准测试时,要规避这些问题,就需要对性能瓶颈和代码中出现的反模式有深入的理解。

需要注意的是,在做字节码注入(bytecode instrumentation)时,一定要注意对侵入性的控制。例如,如果字节码注入工具在应用程序所有的代码中都插入了额外的字节码操作,则很有可能会完全改变应用程序各部分的执行时间,从而导致无法根据执行结果对原系统做出准确的分析。尽管有不少简便的字节码注入工具可以通过代码注入来实现一些特殊功能,例如事件计数器等,但实际这些工具上很少能够做出真正准确的分析,而且使用字节码注入工具时,应用程序不得不重新编译运行。相比之下,JRockit Mission Control套件则可以通过插件的形式在应用程序运行过程中完成作业,而且不会差生额外的执行消耗。

一般来说,通过基准测试或代码注入分析可以找出应用程序的性能瓶颈。这些年来,本书的作者们已经处理过很多性能问题,其中一些往往会重复出现,下面将对这些常见的问题以及规避方法进行介绍。

5.7.1 命令行参数–XXaggressive

在以往的工作中,不止一次看到用户在使用JRockit时会加上了试验性质的命令行参数–XXaggressive,该命令行参数是对其他一些命令行参数的包装,用来通知JRockit快速热身,尽可能快的达到稳定运行状态,使用该参数后,会在启动时消耗更多的资源。由于该参数是试验性质,并且未记录于文档中,故而在不同的JRockit发行版中,该参数所涉及到的优化选项不尽相同,本书的作者建议不要轻易使用该参数。不过在对应用程序的性能做对比分析时,可以将该参数作为其中一种配置加以分析。最后强调一下,使用该参数时,一定要多加小心。

5.7.2 析构函数

正如在3.6.1节中介绍的,就Java来说,析构函数是不安全的,因为它可以复活对象,从而妨碍垃圾回收的执行。此外,JVM在执行这些析构函数时也会产生一些开销。

垃圾回收器会分别跟踪每个将要被释放掉的对象,当调用这些对象finalize方法时都会产生执行开销,如果存在逻辑非常复杂的finalize方法,开销更大。因此,最好不要使用finalize方法。

5.7.3 引用对象

在释放对象时,垃圾回收器对软引用、弱引用和虚引用对象有特殊处理,尽管这些引用对象都有专门的用途(例如实现缓存等等),但如果应用程序中使用了太多的引用对象,仍旧会拖慢垃圾回收器的运行。相比于普通对象,即强引用对象,对其他类型的引用对象做簿记操作(bookkeep)的执行开销会高一个数量级。

使用命令行参数-–Xverbose:refobj可以让JRockit打印出垃圾回收器对Reference对象的处理信息。示例如下:

hastur:material marcus$ java –Xverbose:refobj GarbageCollectionTest
[INFO ][refobj ] [YC#1] SoftRef: Reach: 25 Act: 0 PrevAct: 0 Null: 0
[INFO ][refobj ] [YC#1] WeakRef: Reach: 103 Act: 0 PrevAct: 0 Null: 0
[INFO ][refobj ] [YC#1] Phantom: Reach: 0 Act: 0 PrevAct: 0 Null: 0
[INFO ][refobj ] [YC#1] ClearPh: Reach: 0 Act: 0 PrevAct: 0 Null: 0
[INFO ][refobj ] [YC#1] Finaliz: Reach: 12 Act: 3 PrevAct: 0 Null: 0
[INFO ][refobj ] [YC#1] WeakHnd: Reach: 217 Act: 0 PrevAct: 0 Null: 0
[INFO ][refobj ] [YC#1] SoftRef: @Mark: 25 @Preclean: 0 @FinalMark: 0
[INFO ][refobj ] [YC#1] WeakRef: @Mark: 94 @Preclean: 0 @FinalMark: 9
[INFO ][refobj ] [YC#1] Phantom: @Mark: 0 @Preclean: 0 @FinalMark: 0
[INFO ][refobj ] [YC#1] ClearPh: @Mark: 0 @Preclean: 0 @FinalMark: 0
[INFO ][refobj ] [YC#1] Finaliz: @Mark: 0 @Preclean: 0 @FinalMark: 15
[INFO ][refobj ] [YC#1] WeakHnd: @Mark: 0 @Preclean: 0 @FinalMark: 217
[INFO ][refobj ] [YC#1] SoftRef: SoftAliveOnly: 24 SoftAliveAndReach:1
[INFO ][refobj ] [YC#1] NOTE: This count only applies to a part of the heap.

从上面的示例中可以看出,应用程序中的Reference对象并不对,垃圾回收器处理起来并不费劲。不过,当应用程序中存在大量引用对象时,要小心处理。

5.7.4 对象池

正如在3.7节中介绍的,通常情况下,为了降低内存分配的开销而通过对象池(object pooling)来复用对象,并不能获得良好的效果。

对象池除了会影响垃圾回收器的工作负载和策略调整外,还会延长对象的生命周期,最终使这些对象被提升至老年代,这会带来额外的执行开销,并加速堆的碎片化。大量存活对象是影响垃圾回收器执行性能的一大因素,垃圾回收器主要是针对短生命周期对象进行优化的,而对象池恰恰"贡献"了不少长生命周期的存活对象,不利于垃圾回收工作的执行。

除此之外,以局部性来说,使用新对象而不是池化对象能够更有效的利用缓存。

但凡事无绝对,在某些特殊的应用程序中,内存分配,尤其是内存清零的操作,可能会成为应用程序的性能瓶颈。由于Java要求每个新对象都以默认值初始化,因此内存清零的操作必不可少。当应用程序中需要很多大对象时(例如大数组对象),使用对象池可能会提升整体性能。就JRockit来说,如果优化编译器能够证明内存清零不是必要操作的话,就会在执行优化编译时,剔除内存清零的操作。(译者注,有关剔除内存清零操作的内容,请柬参见5.2.2节

记住,"简单即是美"。

5.7.5 算法与数据结构

从算法与数据结构上讲,就快速查找元素的方法来说,使用哈希表存储元素会比链表好得多;快速排序的时间复杂度是O(nlogn),通常情况下,比冒泡排序的o(n^2)好。在讲解下面的内容时,假设读者已经具备了为最小化算法复杂度而挑选合适的算法和数据结构的能力。

5.7.5.1 典型问题

如果某个第三方应用程序写的很烂,很有可能是滥用数据结构,通过基准测试和审查代码,可以帮助发现并解决性能瓶颈。

public List<Node> breadthFirstSearchSlow(Node root) {
    List<Node> order = new LinkedList<Node>();
    List<Node> queue = new LinkedList<Node>();
    queue.add(root);
    while (!queue.isEmpty()) {
        Node node = queue.remove(0);
        order.add(node);
        for (Node succ : node.getSuccessors()) {
            if (!order.contains(succ) && !queue.contains(succ)) {
                queue.add(succ);
            }
        }
    }
    return order;
}

上面的代码是图的广度优先遍历实现。给定某个根节点后,算法使用队列来存储其子节点,为了避免重复遍历节点,或陷入无限循环,在将某个节点添加到队列之前会检查该节点是否已经被访问过。

在JDK中,LinkedList实例的contain方法通过线性查找的方式来检查是否包含目标元素,因此在最坏情况下,搜索算法的时间复杂度会是O(n^2)(n为待搜索节点数量),当数据量很大时,运行效率较差。

public List<Node> breadthFirstSearchFast(Node root) {
    List<Node> order = new LinkedList<Node>();
    List<Node> queue = new LinkedList<Node>();
    Set<Node> visited = new HashSet<Node>();
    queue.add(root);
    visited.add(root);
    while (!queue.isEmpty()) {
        Node node = queue.remove(0);
        order.add(node);
        for (Node succ : node.getSuccessors()) {
            if (!visited.contains(succ)) {
                queue.add(succ);
                visited.add(succ);
            }
        }
    }
    return order;
}

上面的代码使用了HashSet来记录已经访问过哪些节点,避免了对节点的线性搜索。当数据量很大时,会带来可观的性能提升。

5.7.5.2 意料之外的性质

滥用数据结构还会导致一些其它问题。例如,链表作为了一种通用数据结构,可以很方便实现队列的入队和出队操作。以链表实现队列时,入队和出队的操作具有O(1)的时间复杂度,而无需其他功能的支持。但事情并不这么简单,首先,即使应用程序中不会遍历整个队列,垃圾回收器在工作时,却必须要遍历。

如果队列中包含了大量元素,而整个队列的生命周期又很长,就会给垃圾回收器带来不小的工作量;此外,由于链表是通过对象引用来连接对象的,所以链表中的元素可能存在于堆的任意位置。元素在内存中并非紧挨在一起,会降低缓存的局部性;同时,由于队列元素分布在整个堆中,而且缓存的局部性较差,所以垃圾回收器在执行标记操作时,缓存命中率很低,导致应用程序的暂停时间变长。

因此,看似简单又无害的数据结构却给自动内存管理系统带来了大麻烦。

5.7.6 误用System.gc()

Java语言规范中并未对System.gc()方法的行为做任何保证,假使语言规范中真的对System.gc()的功能做了定义,那么该方法的具体行为可能并不是单纯的执行一次垃圾回收这么简单,每次调用该方法都可能会发生不同的行为。最后重申,不要期望使用System.gc()方法来影响垃圾回收的行为,为安全起见,压根别用System.gc()方法。

5.7.7 线程数太多

将大问题分解为多个独立的小问题,再交给多个线程并行计算,看起来这是个不错的注意,但实际上,线程间的上下文切换也会产生执行开销。在4.3.4节中已经对线程的几种实现做了介绍,无论是绿色线程还是操作系统线程,在执行线程上下文切换时,都无法执行应用程序的业务逻辑。线程间上下文切换操作的次数正比于参与调度的线程数,因此随着线程数增加,上下文切换操作的执行开销也会随之变大。

这里不得不吐槽一下Intel IA-64处理器,它有大量的寄存器,本地线程上下文内容的大小会达到KB级,由于每次上下文切换都需要拷贝这么大的内存数据,因此在Intel IA-64平台上,多线程并行执行的开销会很大。

5.7.8 锁竞争导致性能瓶颈

有竞争的锁往往是性能瓶颈之一,因为竞争意味着多个线程都需要访问同一个资源或者执行同一段代码。有些时候,应用程序中所有的竞争都集中于一个锁,例如使用某个第三方库执行日志记录操作时,每次写日志操作都需要获取全局锁才能完成。当多个线程同时执行写日志的操作时,该全局锁就成为了应用程序的性能瓶颈。

5.7.9 不必要的异常

处理异常需要花时间,而且会打断正常的程序流转。通常情况下,最好不要使用异常来表示执行结果或控制程序流转。

使用异常分析工具找出异常的抛出位置和处理位置是很有用处的,应尽可能移除所有不必要的硬件异常处理,例如空指针异常和除0异常。由于硬件异常一般由操作系统级的硬件中断引起的,因此处理起来开销很大,相比之下,虽然普通的Java异常也有一些开销,但由于会在JVM内部处理,所以执行开销比硬件异常小得多。

从以往的工作经验来看,有些应用程序会使用NullPointerExceptions异常作为控制程序的正常流转,而实际上是不必要的,在剔除这些代码后,应用程序的性能可以获得大幅提升。

在JRockit中,要想找出这些不必要的异常很简单,只需在启动应用程序时加上命令行参数–Xverbose:exceptions,示例如下:

hastur:~ marcus$ java -Xverbose:exceptions Jvm98Wrapper _200_check
  [INFO ][excepti][00004] java/io/FileNotFoundException: /localhome/jrockits/R28.0.0_R28.0.0-454_1.6.0/jre/classes
  [INFO ][excepti][00004] java/lang/ArrayIndexOutOfBoundsException: 6
  [INFO ][excepti][00004] java/lang/ArithmeticException: / by zero
  [INFO ][excepti][00004] java/lang/ArithmeticException: fisk
  [INFO ][excepti][00004] java/lang/ArrayIndexOutOfBoundsException: 11
  [INFO ][excepti][00004] java/lang/RuntimeException: fisk

上面的示例内容中,每行都记录了抛出的异常,若想跟踪异常的抛出轨迹,可以使用命令行参数–Xverbose:exceptions=debug。JRockit Mission Control套件也包含了可以对异常进行分析的框架,而且使用方式更加人性化。示例如下:

hastur:~ marcus$ java -Xverbose:exceptions=debug Jvm98Wrapper _200_check
  [DEBUG][excepti][00004] java/lang/ArrayIndexOutOfBoundsException: 6
    at spec/jbb/validity/PepTest.testArray()Ljava/lang/String;(Unknown Source)
    at spec/jbb/validity/PepTest.instanceMain()V(Unknown Source)
    at spec/jbb/validity/Check.doCheck()Z(Unknown Source)
    at spec/jbb/JBBmain.main([Ljava/lang/String;)V(Unknown Source)
    at jrockit/vm/RNI.c2java(JJJJJ)V(Native Method)
    --- End of stack trace
  [DEBUG][excepti][00004] java/lang/ArithmeticException: / by zero
    at jrockit/vm/Reflect.fillInStackTrace0(Ljava/lang/Throwable;)V(Native Method)
    at java/lang/Throwable.fillInStackTrace()Ljava/lang/Throwable;(Native Method)
    at java/lang/Throwable.<init>(Throwable.java:196)
    at java/lang/Exception.<init>(Exception.java:41)
    at java/lang/RuntimeException.<init>(RuntimeException.java:43)
    at java/lang/ArithmeticException.<init>(ArithmeticException.java:36)
    at jrockit/vm/RNI.c2java(JJJJJ)V(Native Method)
    at jrockit/vm/ExceptionHandler.throwPendingType()V(Native Method)
    at spec/jbb/validity/PepTest.testDiv()Ljava/lang/String;(Unknown Source)
    at spec/jbb/validity/PepTest.instanceMain()V(Unknown Source)
    at spec/jbb/validity/Check.doCheck()Z(Unknown Source)
    at spec/jbb/JBBmain.main([Ljava/lang/String;)V(Unknown Source)
    at jrockit/vm/RNI.c2java(JJJJJ)V(Native Method)
    --- End of stack trace
  [DEBUG][excepti][00004] java/lang/ArithmeticException: fisk
    at spec/jbb/validity/PepTest.testExc1()Ljava/lang/String;(Unknown Source)
    at spec/jbb/validity/PepTest.instanceMain()V(Unknown Source)
    at spec/jbb/validity/Check.doCheck()Z(Unknown Source)
    at spec/jbb/JBBmain.main([Ljava/lang/String;)V(Unknown Source)
    at jrockit/vm/RNI.c2java(JJJJJ)V(Native Method)
    --- End of stack trace
  [DEBUG][excepti][00004] java/lang/ArrayIndexOutOfBoundsException: 11
    at spec/jbb/validity/PepTest.testExc1()Ljava/lang/String;(Unknown Source)
    at spec/jbb/validity/PepTest.instanceMain()V(Unknown Source)
    at spec/jbb/validity/Check.doCheck()Z(Unknown Source)
    at spec/jbb/JBBmain.main([Ljava/lang/String;)V(Unknown Source)
    at jrockit/vm/RNI.c2java(JJJJJ)V(Native Method)
    --- End of stack trace
  [DEBUG][excepti][00004] java/lang/RuntimeException: fisk
    at spec/jbb/validity/PepTest.testExc2()Ljava/lang/String;(Unknown Source)
    at spec/jbb/validity/PepTest.instanceMain()V(Unknown Source)
    at spec/jbb/validity/Check.doCheck()Z(Unknown Source)
    at spec/jbb/JBBmain.main([Ljava/lang/String;)V(Unknown Source)
    at jrockit/vm/RNI.c2java(JJJJJ)V(Native Method)
    --- End of stack trace

JRockit Flight Recorder中也包含了异常分析功能,可以用JRockit Mission Control来分析相关内容。

5.7.10 大对象

有时候,为大对象分配内存时,并不使用TLA,而是直接分配在堆中,这是因为一般情况下TLA的容量并不大,在TLA分配大对象会导致频繁将对象提升到堆中的操作,带来不必要的执行开销。就JRockit来说,在R28版本之前,会显式设定大对象的阈值,凡是小于此阈值的对象会分配在TLA中。从R28版本起,使用 浪费限额(waste limit)控制TLA中大对象的分配策略。

把大对象分配在堆中其实也不能高枕无忧,它们会加速堆的碎片化,因为大对象可能无法填补回收小对象后留下的空隙。

如果堆的碎片比较严重,为对象分配内存的时间就会大幅增长,而大对象本身又会加速碎片化的进程。此外,由于大对象会跳过TLA而直接分配在堆中,因此分配内存的操作时可能会参与对堆的全局锁的竞争,从而增加了执行开销。

在最坏情况下,滥用大对象会导致垃圾回收器频繁地对整个堆执行垃圾回收,此举破坏性极强,会长时间中断应用程序的执行。

对于给定的应用程序来说,很难找到一个大对象的阈值能适用于所有情况,因此在JRockit中,这个阈值(从R28版本起改为浪费限额)是会变化的,这种机制非常有用,例如当自适应运行时发现有相当一部分对象的大小比默认值稍大时,或者自适应运行时期望TLA中内容能更紧密的排列时,就可以通过修改这个阈值(或浪费限额)来达到目的。

当频繁出现MB级别的大对象时,最糟糕的情况出现了,典型场景是在堆中存储了数据库查询结果,或是使用非常大的数组。记住,坚决避免这种用法,即使是专门实现一个本地存储层来放置超大对象,也不要将这么大的对象放在堆中。

5.7.11 本地内存 vs. 堆内存

对于JVM来说,所有的内存都是来自于操作系统的系统内存,JVM将其中一部分用来实现堆,堆大小初始值和最大值可分别通过命令行参数-Xms-Xmx来设置

当系统内存不足以完成JVM内部的某些操作,或JVM无法为对象在堆中分配到足够的内存时,JVM会抛出OutOfMemoryError错误。

作为一个本地应用程序,JVM本身也会消耗一些系统内存,例如代码优化操作等。从大的方面讲,JVM内部的内存管理是独立于Java堆之外的,是通过类似malloc的系统调用从系统内存中直接分配的,这部分由JVM直接分配的、并非作为堆使用的系统内存称为 本地内存(native memory)

堆内存可以由JVM的垃圾回收器来回收,而本地内存却不行。如果所有的本地内存都由JVM自己来管理,而且JVM在使用本地内存时又足够聪明的话,那就万事大吉了,但现实是残酷的。

在某些场景中,本地内存可能会被耗尽,例如,JVM的代码优化操作是很耗费本地内存的,当JVM使用多线程并行执行代码优化时,就有可能耗尽所有的本地内存。此外,还有一些其他的机制,譬如JNI,可以让Java也鞥操作本地内存,如果在JNI调用中分配了一块很大的内存,则在其被释放掉之前,JVM本身是无法使用这块内存的。

JRockit中包含了跟踪本地内存使用率的机制,可以通过JRockit Mission Control或JRCMD查看相关信息,其中的直方图显示了本地内存的使用情况。

如果堆设置得太大,有可能会导致JVM留给自己用来做簿记或代码优化等操作的本地内存不太够用,此时,JVM别无他法,只好从本地代码中抛出OutOfMemoryError错误。就JRockit来说,可以通过降低命令行参数-Xmx的值来隐式地增加本地内存的可用量。